Writing an analyzer for both console and editor
With a little orchestration it is possible to easily write two analyzer functions that share a common implementation.
open FSharp.Analyzers.SDK
open FSharp.Analyzers.SDK.ASTCollecting
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.Text
open FSharp.Compiler.Syntax
/// This analyzer function will try and detect if any `System.*` open statement was found after any non System open.
/// See https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions#sort-open-statements-topologically
/// Note that this implementation is not complete and only serves as an illustration.
/// Nested modules are not taken into account.
let private topologicallySortedOpenStatementsAnalyzer
(sourceText: ISourceText)
(untypedTree: ParsedInput)
(checkResults: FSharpCheckFileResults)
: Async<Message list>
=
async {
let allOpenStatements =
let allOpenStatements = ResizeArray<string list * range>()
let (|LongIdentAsString|) (lid: SynLongIdent) =
lid.LongIdent
|> List.map (fun ident -> ident.idText)
let walker =
{ new SyntaxCollectorBase() with
override _.WalkSynModuleSigDecl(_, decl: SynModuleSigDecl) =
match decl with
| SynModuleSigDecl.Open(
target = SynOpenDeclTarget.ModuleOrNamespace(
longId = LongIdentAsString value; range = mOpen)) ->
allOpenStatements.Add(value, mOpen)
| _ -> ()
override _.WalkSynModuleDecl(_, decl: SynModuleDecl) =
match decl with
| SynModuleDecl.Open(
target = SynOpenDeclTarget.ModuleOrNamespace(
longId = LongIdentAsString value; range = mOpen)) ->
allOpenStatements.Add(value, mOpen)
| _ -> ()
}
ASTCollecting.walkAst walker untypedTree
allOpenStatements
|> Seq.toList
let isSystemOpenStatement (openStatement: string list, mOpen: range) =
let isFromBCL () =
let line = sourceText.GetLineString(mOpen.EndLine - 1)
match
checkResults.GetSymbolUseAtLocation(
mOpen.EndLine,
mOpen.EndColumn,
line,
openStatement
)
with
| Some symbolUse ->
match symbolUse.Symbol.Assembly.FileName with
| None -> false
| Some assemblyPath ->
// This might not be an airtight check
assemblyPath.ToLower().Contains "microsoft.netcore.app.ref"
| _ -> false
openStatement.[0].StartsWith("System")
&& isFromBCL ()
let nonSystemOpens =
allOpenStatements
|> List.skipWhile isSystemOpenStatement
return
nonSystemOpens
|> List.filter isSystemOpenStatement
|> List.map (fun (openStatement, mOpen) ->
let openStatementText =
openStatement
|> String.concat "."
{
Type = "Unsorted System open statement"
Message =
$"%s{openStatementText} was found after non System namespaces where opened!"
Code = "SOT001"
Severity = Severity.Warning
Range = mOpen
Fixes = []
}
)
}
[<CliAnalyzer "Topologically sorted open statements">]
let cliAnalyzer (ctx: CliContext) : Async<Message list> =
topologicallySortedOpenStatementsAnalyzer
ctx.SourceText
ctx.ParseFileResults.ParseTree
ctx.CheckFileResults
[<EditorAnalyzer "Topologically sorted open statements">]
let editorAnalyzer (ctx: EditorContext) : Async<Message list> =
match ctx.CheckFileResults with
// The editor might not have any check results for a given file. So we don't return any messages.
| None -> async.Return []
| Some checkResults ->
topologicallySortedOpenStatementsAnalyzer
ctx.SourceText
ctx.ParseFileResults.ParseTree
checkResults
Both analyzers will follow the same code path: the console application will always have the required data, while the editor needs to be more careful.
⚠️ Please do not be tempted by calling .Value on the EditorContext 😉.
To enable a wide range of analyzers, both context types give access to very detailed information about the source code.
Among this information is the full untyped abstract syntax tree (AST) and the typed abstract syntax tree (TAST).
As you can deduce from the example above, processing these trees is a very common task in an analyzer. But writing your own tree traversal code can be daunting and can also get quite repetitive over many analyzers.
That's why the SDK offers the ASTCollecting and TASTCollecting modules. In there, you'll find facility types and functions to make your analyzers author life easier.
For both trees, a type is defined, SyntaxCollectorBase and TypedTreeCollectorBase respectively,
with members you can override to have easy access to the tree elements you want to process.
Just pass an instance with your overriden members to the walkAst or walkTast function.
The open-statement analyzer from above uses the AST for it's analysis.
Because we want to process the SynModuleSigDecl and SynModuleDecl elements of the AST, we just override the two appropriate members of the SyntaxCollectorBase type
in an object expression and pass the instance to walkAst.
Much simpler and shorter than doing the traversal ourselves.
namespace FSharp
--------------------
namespace Microsoft.FSharp
from FSharp.Analyzers.SDK
See https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions#sort-open-statements-topologically
Note that this implementation is not complete and only serves as an illustration.
Nested modules are not taken into account.
val private topologicallySortedOpenStatementsAnalyzer:
sourceText: ISourceText ->
untypedTree: ParsedInput ->
checkResults: FSharpCheckFileResults ->
Async<Message list>
abstract ContentEquals: sourceText: ISourceText -> bool
abstract CopyTo:
sourceIndex: int *
destination: char array *
destinationIndex: int *
count: int ->
unit
abstract GetLastCharacterPosition: unit -> int * int
abstract GetLineCount: unit -> int
abstract GetLineString: lineIndex: int -> string
abstract GetSubTextFromRange: range: range -> string
abstract GetSubTextString: start: int * length: int -> string
abstract SubTextEquals: target: string * startIndex: int -> bool
abstract Item: index: int -> char with get
abstract Length: int
module ParsedInput
from FSharp.Compiler.Syntax
--------------------
type ParsedInput =
| ImplFile of ParsedImplFileInput
| SigFile of ParsedSigFileInput
member FileName: string
member Identifiers: Set<string>
member QualifiedName: QualifiedNameOfFile
member Range: range
member GenerateSignature: ?pageWidth: int -> ISourceText option
member GetAllUsesOfAllSymbolsInFile:
?cancellationToken: CancellationToken ->
FSharpSymbolUse seq
member GetDeclarationListInfo:
parsedFileResults: FSharpParseFileResults option *
line: int *
lineText: string *
partialName: PartialLongName *
?getAllEntities: (unit -> AssemblySymbol list) *
?completionContextAtPos: (pos * CompletionContext option) *
?options: FSharpCodeCompletionOptions ->
DeclarationListInfo
member GetDeclarationListSymbols:
parsedFileResults: FSharpParseFileResults option *
line: int *
lineText: string *
partialName: PartialLongName *
?getAllEntities: (unit -> AssemblySymbol list) *
?options: FSharpCodeCompletionOptions ->
FSharpSymbolUse list list
member GetDeclarationLocation:
line: int *
colAtEndOfNames: int *
lineText: string *
names: string list *
?preferFlag: bool ->
FindDeclResult
member GetDescription:
symbol: FSharpSymbol *
inst: (FSharpGenericParameter * FSharpType) list *
displayFullName: bool *
range: range ->
ToolTipText
member GetDisplayContextForPos: cursorPos: pos -> FSharpDisplayContext option
member GetF1Keyword:
line: int *
colAtEndOfNames: int *
lineText: string *
names: string list ->
string option
member GetFormatSpecifierLocationsAndArity: unit -> (range * int) array
member GetKeywordTooltip: names: string list -> ToolTipText
...
type Async =
static member AsBeginEnd:
computation: ('Arg -> Async<'T>) ->
('Arg * AsyncCallback * objnull -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit)
static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate and 'Del: not null)
static member AwaitIAsyncResult:
iar: IAsyncResult *
?millisecondsTimeout: int ->
Async<bool>
static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload
static member AwaitWaitHandle:
waitHandle: WaitHandle *
?millisecondsTimeout: int ->
Async<bool>
static member CancelDefaultToken: unit -> unit
static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>>
static member Choice: computations: Async<'T option> seq -> Async<'T option>
static member FromBeginEnd: beginAction: (AsyncCallback * objnull -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads
static member FromContinuations:
callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) ->
Async<'T>
...
--------------------
type Async<'T>
{
Type: string
Message: string
Code: string
Severity: Severity
Range: range
Fixes: Fix list
}
val string: value: 'T -> string
--------------------
type string = System.String
union case SynLongIdent.SynLongIdent:
id: LongIdent *
dotRanges: range list *
trivia: FSharp.Compiler.SyntaxTrivia.IdentTrivia option list ->
SynLongIdent
--------------------
type SynLongIdent =
| SynLongIdent of id:
LongIdent *
dotRanges: range list *
trivia: IdentTrivia option list
member Dots: range list
member IdentsWithTrivia: SynIdent list
member LongIdent: LongIdent
member Range: range
member RangeWithoutAnyExtraDot: range
member ThereIsAnExtraDotAtTheEnd: bool
member Trivia: IdentTrivia list
module List
from Microsoft.FSharp.Collections
--------------------
type List<'T> =
| op_Nil
| op_ColonColon of Head: 'T * Tail: 'T list
interface IReadOnlyList<'T>
interface IReadOnlyCollection<'T>
interface IEnumerable
interface IEnumerable<'T>
member GetReverseIndex: rank: int * offset: int -> int
member GetSlice: startIndex: int option * endIndex: int option -> 'T list
static member Cons: head: 'T * tail: 'T list -> 'T list
member Head: 'T
member IsEmpty: bool
member Item: index: int -> 'T with get
...
The members of this type are called by walkAst.
By overwriting the members for various syntax elements, a custom operation can be executed for them.
type SyntaxCollectorBase =
new: unit -> SyntaxCollectorBase
abstract WalkAttribute: path: SyntaxVisitorPath * attribute: SynAttribute -> unit + 1 overload
abstract WalkBinding: path: SyntaxVisitorPath * binding: SynBinding -> unit + 1 overload
abstract WalkClause: path: SyntaxVisitorPath * matchClause: SynMatchClause -> unit + 1 overload
abstract WalkComponentInfo: path: SyntaxVisitorPath * componentInfo: SynComponentInfo -> unit + 1 overload
abstract WalkEnumCase: path: SyntaxVisitorPath * enumCase: SynEnumCase -> unit + 1 overload
abstract WalkExpr: path: SyntaxVisitorPath * expr: SynExpr -> unit + 1 overload
abstract WalkField: path: SyntaxVisitorPath * field: SynField -> unit + 1 overload
abstract WalkInterfaceImpl: path: SyntaxVisitorPath * interfaceImpl: SynInterfaceImpl -> unit + 1 overload
abstract WalkInterpolatedStringPart: path: SyntaxVisitorPath * interpolatedStringPart: SynInterpolatedStringPart -> unit + 1 overload
...
--------------------
new: unit -> SyntaxCollectorBase
| ModuleAbbrev of ident: Ident * longId: LongIdent * range: range
| NestedModule of moduleInfo:
SynComponentInfo *
isRecursive: bool *
moduleDecls: SynModuleSigDecl list *
range: range *
trivia: SynModuleSigDeclNestedModuleTrivia
| Val of valSig: SynValSig * range: range
| Types of types: SynTypeDefnSig list * range: range
| Exception of exnSig: SynExceptionSig * range: range
| Open of target: SynOpenDeclTarget * range: range
| HashDirective of hashDirective: ParsedHashDirective * range: range
| NamespaceFragment of SynModuleOrNamespaceSig
member Range: range
target: SynOpenDeclTarget *
range: range ->
SynModuleSigDecl
| ModuleOrNamespace of longId: SynLongIdent * range: range
| Type of typeName: SynType * range: range
member Range: range
longId: SynLongIdent *
range: range ->
SynOpenDeclTarget
| ModuleAbbrev of ident: Ident * longId: LongIdent * range: range
| NestedModule of moduleInfo:
SynComponentInfo *
isRecursive: bool *
decls: SynModuleDecl list *
isContinuing: bool *
range: range *
trivia: SynModuleDeclNestedModuleTrivia
| Let of isRecursive:
bool *
bindings: SynBinding list *
range: range *
trivia: SynModuleDeclLetTrivia
| Expr of expr: SynExpr * range: range
| Types of typeDefns: SynTypeDefn list * range: range
| Exception of exnDefn: SynExceptionDefn * range: range
| Open of target: SynOpenDeclTarget * range: range
| Attributes of attributes: SynAttributes * range: range
| HashDirective of hashDirective: ParsedHashDirective * range: range
| NamespaceFragment of fragment: SynModuleOrNamespace
member Range: range
target: SynOpenDeclTarget *
range: range ->
SynModuleDecl
to process the syntax elements.
val walkAst: walker: SyntaxCollectorBase -> input: ParsedInput -> unit
from Microsoft.FSharp.Collections
line: int *
colAtEndOfNames: int *
lineText: string *
names: string list ->
FSharpSymbolUse option
System.String.ToLower(culture: System.Globalization.CultureInfo) : string
from Microsoft.FSharp.Core
| Info
| Hint
| Warning
| Error
module Range
from FSharp.Compiler.Text
--------------------
type Range =
member ApplyLineDirectives: unit -> range
member End: pos
member EndColumn: int
member EndLine: int
member EndRange: range
member FileName: string
member IsSynthetic: bool
member Start: pos
member StartColumn: int
member StartLine: int
...
Marks an analyzer for scanning during the console application run.
type CliAnalyzerAttribute =
inherit AnalyzerAttribute
new:
[<Optional; DefaultParameterValue (("Analyzer" :> obj))>] name: string *
[<Optional; DefaultParameterValue (("" :> obj))>] shortDescription: string *
[<Optional; DefaultParameterValue (("" :> obj))>] helpUri: string ->
CliAnalyzerAttribute
member Name: string
--------------------
new:
[<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("Analyzer" :> obj))>] name: string *
[<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] shortDescription: string *
[<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] helpUri: string ->
CliAnalyzerAttribute
Contains the source text, untyped and typed tree information.
type CliContext =
{
FileName: string
SourceText: ISourceText
ParseFileResults: FSharpParseFileResults
CheckFileResults: FSharpCheckFileResults
TypedTree: FSharpImplementationFileContents option
CheckProjectResults: FSharpCheckProjectResults
ProjectOptions: AnalyzerProjectOptions
AnalyzerIgnoreRanges: Map<string,AnalyzerIgnoreRange list>
}
interface Context
member GetAllEntities: publicOnly: bool -> AssemblySymbol list
member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq
member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
See ISourceText Type
CliContext.SourceText: ISourceText
See FSharpParseFileResults Type
CliContext.ParseFileResults: FSharpParseFileResults
See FSharpCheckFileResults Type
CliContext.CheckFileResults: FSharpCheckFileResults
Marks an analyzer for scanning during IDE integration.
type EditorAnalyzerAttribute =
inherit AnalyzerAttribute
new:
[<Optional; DefaultParameterValue (("Analyzer" :> obj))>] name: string *
[<Optional; DefaultParameterValue (("" :> obj))>] shortDescription: string *
[<Optional; DefaultParameterValue (("" :> obj))>] helpUri: string ->
EditorAnalyzerAttribute
member Name: string
--------------------
new:
[<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("Analyzer" :> obj))>] name: string *
[<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] shortDescription: string *
[<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] helpUri: string ->
EditorAnalyzerAttribute
The available contents is controlled based on what information the IDE has available.
type EditorContext =
{
FileName: string
SourceText: ISourceText
ParseFileResults: FSharpParseFileResults
CheckFileResults: FSharpCheckFileResults option
TypedTree: FSharpImplementationFileContents option
CheckProjectResults: FSharpCheckProjectResults option
ProjectOptions: AnalyzerProjectOptions
AnalyzerIgnoreRanges: Map<string,AnalyzerIgnoreRange list>
}
interface Context
member GetAllEntities: publicOnly: bool -> AssemblySymbol list
member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq
member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
See FSharpCheckFileResults Type
EditorContext.CheckFileResults: FSharpCheckFileResults option
See ISourceText Type
EditorContext.SourceText: ISourceText
See FSharpParseFileResults Type
EditorContext.ParseFileResults: FSharpParseFileResults
FSharp.Analyzers.SDK